/*
 * DAELoader.java 16 Mai 2010
 *
 * Sweet Home 3D, Copyright (c) 2010 Emmanuel PUYBARET / eTeks <info@eteks.com>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */
package com.eteks.sweethome3d.j3d;

import java.awt.image.BufferedImage;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Stack;

import javax.imageio.ImageIO;
import javax.media.j3d.Appearance;
import javax.media.j3d.BoundingBox;
import javax.media.j3d.Bounds;
import javax.media.j3d.BranchGroup;
import javax.media.j3d.ColoringAttributes;
import javax.media.j3d.Geometry;
import javax.media.j3d.Group;
import javax.media.j3d.IndexedGeometryArray;
import javax.media.j3d.IndexedLineArray;
import javax.media.j3d.IndexedLineStripArray;
import javax.media.j3d.Link;
import javax.media.j3d.Material;
import javax.media.j3d.Node;
import javax.media.j3d.PolygonAttributes;
import javax.media.j3d.Shape3D;
import javax.media.j3d.SharedGroup;
import javax.media.j3d.Texture;
import javax.media.j3d.Transform3D;
import javax.media.j3d.TransformGroup;
import javax.media.j3d.TransparencyAttributes;
import javax.vecmath.AxisAngle4f;
import javax.vecmath.Color3f;
import javax.vecmath.Point3d;
import javax.vecmath.Vector3d;
import javax.vecmath.Vector3f;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;

import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import com.sun.j3d.loaders.IncorrectFormatException;
import com.sun.j3d.loaders.Loader;
import com.sun.j3d.loaders.LoaderBase;
import com.sun.j3d.loaders.ParsingErrorException;
import com.sun.j3d.loaders.Scene;
import com.sun.j3d.loaders.SceneBase;
import com.sun.j3d.utils.geometry.GeometryInfo;
import com.sun.j3d.utils.geometry.NormalGenerator;
import com.sun.j3d.utils.image.TextureLoader;

/**
 * A loader for DAE Collada 1.4.1 format as specified by
 * <a href="http://www.khronos.org/files/collada_spec_1_4.pdf">http://www.khronos.org/files/collada_spec_1_4.pdf</a>.
 * All texture coordinates are considered to belong to the same set (for example UVSET0).<br>
 * Note: this class is compatible with Java 3D 1.3.
 * @author Emmanuel Puybaret
 * @author apptaro (bug fixes)
 */
public class DAELoader extends LoaderBase implements Loader
{
	private Boolean useCaches;
	
	/**
	 * Sets whether this loader should try or avoid accessing to URLs with cache.
	 * @param useCaches <code>Boolean.TRUE</code>, <code>Boolean.FALSE</code>, or 
	 *    <code>null</code> then caches will be used according to the value 
	 *    returned by {@link URLConnection#getDefaultUseCaches()}.
	 */
	public void setUseCaches(Boolean useCaches)
	{
		this.useCaches = Boolean.valueOf(useCaches);
	}
	
	/**
	 * Returns the scene described in the given DAE file.
	 */
	public Scene load(String file) throws FileNotFoundException, IncorrectFormatException, ParsingErrorException
	{
		URL baseUrl;
		try
		{
			if (this.basePath != null)
			{
				baseUrl = new File(this.basePath).toURI().toURL();
			}
			else
			{
				baseUrl = new File(file).toURI().toURL();
			}
		}
		catch (MalformedURLException ex)
		{
			throw new FileNotFoundException(file);
		}
		return load(new BufferedInputStream(new FileInputStream(file)), baseUrl);
	}
	
	/**
	 * Returns the scene described in the given DAE file url.
	 */
	public Scene load(URL url) throws FileNotFoundException, IncorrectFormatException, ParsingErrorException
	{
		URL baseUrl = this.baseUrl;
		if (this.baseUrl == null)
		{
			baseUrl = url;
		}
		InputStream in;
		try
		{
			in = openStream(url);
		}
		catch (IOException ex)
		{
			throw new FileNotFoundException("Can't read " + url);
		}
		return load(new BufferedInputStream(in), baseUrl);
	}
	
	/**
	 * Returns an input stream ready to read data from the given URL.
	 */
	private InputStream openStream(URL url) throws IOException
	{
		URLConnection connection = url.openConnection();
		if (this.useCaches != null)
		{
			connection.setUseCaches(this.useCaches.booleanValue());
		}
		return connection.getInputStream();
	}
	
	/**
	 * Returns the scene described in the given DAE file stream.
	 */
	public Scene load(Reader reader) throws FileNotFoundException, IncorrectFormatException, ParsingErrorException
	{
		throw new UnsupportedOperationException();
	}
	
	/**
	 * Returns the scene described in the given DAE file.
	 */
	private Scene load(InputStream in, URL baseUrl) throws FileNotFoundException
	{
		try
		{
			return parseXMLStream(in, baseUrl);
		}
		catch (IOException ex)
		{
			throw new ParsingErrorException(ex.getMessage());
		}
		finally
		{
			try
			{
				in.close();
			}
			catch (IOException ex)
			{
				throw new ParsingErrorException(ex.getMessage());
			}
		}
	}
	
	/**
	 * Returns the scene parsed from a Collada XML stream. 
	 */
	private Scene parseXMLStream(InputStream in, URL baseUrl) throws IOException
	{
		try
		{
			SceneBase scene = new SceneBase();
			SAXParserFactory factory = SAXParserFactory.newInstance();
			factory.setValidating(false);
			SAXParser saxParser = factory.newSAXParser();
			saxParser.parse(in, new DAEHandler(scene, baseUrl));
			return scene;
		}
		catch (ParserConfigurationException ex)
		{
			IOException ex2 = new IOException("Can't parse XML stream");
			ex2.initCause(ex);
			throw ex2;
		}
		catch (SAXException ex)
		{
			IOException ex2 = new IOException("Can't parse XML stream");
			ex2.initCause(ex);
			throw ex2;
		}
	}
	
	/**
	 * SAX handler for DAE Collada stream.
	 */
	private class DAEHandler extends DefaultHandler
	{
		private final SceneBase scene;
		private final URL baseUrl;
		private final Stack<Group> parentGroups = new Stack<Group>();
		private final Stack<String> parentElements = new Stack<String>();
		private final StringBuilder buffer = new StringBuilder();
		private final List<Runnable> postProcessingBinders = new ArrayList<Runnable>();
		
		private final Map<String, Texture> textures = new HashMap<String, Texture>();
		private final Map<String, Appearance> effectAppearances = new HashMap<String, Appearance>();
		private final Map<String, String> materialEffects = new HashMap<String, String>();
		private final Map<String, String> materialNames = new HashMap<String, String>();
		private final Map<String, String> surface2DIds = new HashMap<String, String>();
		private final Map<String, String> sampler2DIds = new HashMap<String, String>();
		private final Map<String, List<Geometry>> geometries = new HashMap<String, List<Geometry>>();
		private final Map<String, float[]> sources = new HashMap<String, float[]>();
		private final Map<String, float[]> positions = new HashMap<String, float[]>();
		private final Map<String, float[]> normals = new HashMap<String, float[]>();
		private final Map<String, float[]> textureCoordinates = new HashMap<String, float[]>();
		private final Map<String, float[]> floatArrays = new HashMap<String, float[]>();
		private final Map<float[], Integer> sourceAccessorStrides = new HashMap<float[], Integer>();
		private final Map<Geometry, String> geometryAppearances = new HashMap<Geometry, String>();
		private final List<int[]> facesAndLinesPrimitives = new ArrayList<int[]>();
		private final List<int[]> polygonsPrimitives = new ArrayList<int[]>();
		private final List<List<int[]>> polygonsHoles = new ArrayList<List<int[]>>();
		private final Map<String, TransformGroup> nodes = new HashMap<String, TransformGroup>();
		private final Map<String, SharedGroup> instantiatedNodes = new HashMap<String, SharedGroup>();
		private final Map<String, TransformGroup> visualScenes = new HashMap<String, TransformGroup>();
		private TransformGroup visualScene;
		private float[] floats;
		private float[] geometryVertices;
		private float[] geometryNormals;
		private float[] geometryTextureCoordinates;
		private int[] vcount;
		private float[] transparentColor;
		private Float transparency;
		
		private boolean inRootAsset;
		private boolean reverseTransparency;
		private String imageId;
		private String materialId;
		private String effectId;
		private String newParamSid;
		private boolean inSurface2D;
		private boolean inPhongBlinnOrLambert;
		private String techniqueProfile;
		private boolean inConstant;
		private String geometryId;
		private String meshSourceId;
		private String verticesId;
		private String floatArrayId;
		private String geometryAppearance;
		private int geometryVertexOffset;
		private int geometryNormalOffset;
		private int geometryTextureCoordinatesOffset;
		private String axis;
		private float meterScale = 1f;
		private float floatValue;
		private String opaque;
		private int inputCount;
		
		public DAEHandler(SceneBase scene, URL baseUrl)
		{
			this.scene = scene;
			this.baseUrl = baseUrl;
		}
		
		@Override
		public void startElement(String uri, String localName, String name, Attributes attributes) throws SAXException
		{
			buffer.setLength(0);
			String parent = this.parentElements.isEmpty() ? null : this.parentElements.peek();
			if (parent == null && !"COLLADA".equals(name))
			{
				throw new SAXException("Expected COLLADA element");
			}
			else if ("COLLADA".equals(name))
			{
				String version = attributes.getValue("version");
				if (!version.startsWith("1.4"))
				{
					throw new SAXException("Version " + version + " not supported");
				}
			}
			else if ("COLLADA".equals(parent) && "asset".equals(name))
			{
				this.inRootAsset = true;
			}
			else if ("asset".equals(parent) && "unit".equals(name))
			{
				String value = attributes.getValue("meter");
				if (value != null)
				{
					this.meterScale = Float.parseFloat(value);
				}
			}
			else if ("image".equals(name))
			{
				this.imageId = attributes.getValue("id");
			}
			else if ("material".equals(name))
			{
				this.materialId = attributes.getValue("id");
				this.materialNames.put(this.materialId, attributes.getValue("name"));
			}
			else if ("material".equals(parent) && "instance_effect".equals(name))
			{
				String effectInstanceUrl = attributes.getValue("url");
				if (effectInstanceUrl.startsWith("#"))
				{
					final String effectInstanceAnchor = effectInstanceUrl.substring(1);
					this.materialEffects.put(this.materialId, effectInstanceAnchor);
				}
			}
			else if ("effect".equals(name))
			{
				this.effectId = attributes.getValue("id");
				this.effectAppearances.put(this.effectId, new Appearance());
			}
			else if (this.effectId != null)
			{
				if ("profile_COMMON".equals(parent) && "newparam".equals(name))
				{
					this.newParamSid = attributes.getValue("sid");
				}
				else if ("newparam".equals(parent) && "surface".equals(name)
						&& "2D".equals(attributes.getValue("type")))
				{
					this.inSurface2D = true;
				}
				else if ("extra".equals(parent) && "technique".equals(name))
				{
					this.techniqueProfile = attributes.getValue("profile");
				}
				else if ("phong".equals(name) || "blinn".equals(name))
				{
					this.inPhongBlinnOrLambert = true;
				}
				else if ("lambert".equals(name))
				{
					this.inPhongBlinnOrLambert = true;
					getAppearanceMaterial(this.effectId).setSpecularColor(0, 0, 0);
					getAppearanceMaterial(this.effectId).setShininess(1);
				}
				else if ("constant".equals(name))
				{
					this.inConstant = true;
				}
				else if (this.inConstant || this.inPhongBlinnOrLambert && ("transparent".equals(name)))
				{
					this.opaque = attributes.getValue("opaque");
					if (this.opaque == null)
					{
						this.opaque = "A_ONE";
					}
				}
				else if ("texture".equals(name) && "diffuse".equals(parent))
				{
					final String textureId = this.surface2DIds
							.get(this.sampler2DIds.get(attributes.getValue("texture")));
					final Appearance appearance = this.effectAppearances.get(this.effectId);
					this.postProcessingBinders.add(new Runnable()
					{
						public void run()
						{
							// Resolve texture at the end of the document
							appearance.setTexture(textures.get(textureId));
						}
					});
				}
			}
			else if ("geometry".equals(name))
			{
				this.geometryId = attributes.getValue("id");
				this.geometries.put(this.geometryId, new ArrayList<Geometry>());
			}
			else if (this.geometryId != null)
			{
				if ("mesh".equals(parent) && "source".equals(name))
				{
					this.meshSourceId = attributes.getValue("id");
				}
				else if ("mesh".equals(parent) && "vertices".equals(name))
				{
					this.verticesId = attributes.getValue("id");
				}
				else if (this.meshSourceId != null)
				{
					if ("float_array".equals(name))
					{
						this.floatArrayId = attributes.getValue("id");
					}
					else if ("technique_common".equals(parent) && "accessor".equals(name))
					{
						String floatArrayAnchor = attributes.getValue("source").substring(1);
						String stride = attributes.getValue("stride");
						this.sourceAccessorStrides.put(this.floatArrays.get(floatArrayAnchor),
								stride != null ? Integer.valueOf(stride) : 1);
					}
				}
				else if (this.verticesId != null && "input".equals(name))
				{
					String sourceAnchor = attributes.getValue("source").substring(1);
					if ("POSITION".equals(attributes.getValue("semantic")))
					{
						this.positions.put(this.verticesId, this.sources.get(sourceAnchor));
					}
					else if ("NORMAL".equals(attributes.getValue("semantic")))
					{
						this.normals.put(this.verticesId, this.sources.get(sourceAnchor));
					}
					else if ("TEXCOORD".equals(attributes.getValue("semantic")))
					{
						this.textureCoordinates.put(this.verticesId, this.sources.get(sourceAnchor));
					}
				}
				else if (this.verticesId == null && "input".equals(name))
				{
					String sourceAnchor = attributes.getValue("source").substring(1);
					int offset = Integer.parseInt(attributes.getValue("offset"));
					if (this.inputCount < offset + 1)
					{
						this.inputCount = offset + 1;
					}
					if ("VERTEX".equals(attributes.getValue("semantic")))
					{
						this.geometryVertices = this.positions.get(sourceAnchor);
						this.geometryVertexOffset = offset;
						if (this.geometryNormals == null)
						{
							this.geometryNormals = this.normals.get(sourceAnchor);
							this.geometryNormalOffset = offset;
						}
						if (this.geometryTextureCoordinates == null)
						{
							this.geometryTextureCoordinates = this.textureCoordinates.get(sourceAnchor);
							this.geometryTextureCoordinatesOffset = offset;
						}
					}
					else if ("NORMAL".equals(attributes.getValue("semantic")))
					{
						this.geometryNormals = this.sources.get(sourceAnchor);
						this.geometryNormalOffset = offset;
					}
					else if ("TEXCOORD".equals(attributes.getValue("semantic")))
					{
						this.geometryTextureCoordinates = this.sources.get(sourceAnchor);
						this.geometryTextureCoordinatesOffset = offset;
					}
				}
				else if ("triangles".equals(name) || "trifans".equals(name) || "tristrips".equals(name)
						|| "polylist".equals(name) || "polygons".equals(name) || "lines".equals(name)
						|| "linestrips".equals(name))
				{
					this.geometryAppearance = attributes.getValue("material");
					this.inputCount = 0;
					this.facesAndLinesPrimitives.clear();
					this.polygonsPrimitives.clear();
					this.polygonsHoles.clear();
					this.vcount = null;
				}
			}
			else if ("visual_scene".equals(name))
			{
				TransformGroup visualSceneGroup = new TransformGroup();
				this.parentGroups.push(visualSceneGroup);
				this.visualScenes.put(attributes.getValue("id"), visualSceneGroup);
			}
			else if ("node".equals(name))
			{
				TransformGroup nodeGroup = new TransformGroup();
				if (this.parentGroups.size() > 0)
				{
					// Add node to parent node only for children nodes
					this.parentGroups.peek().addChild(nodeGroup);
				}
				this.parentGroups.push(nodeGroup);
				this.nodes.put(attributes.getValue("id"), nodeGroup);
				String nodeName = attributes.getValue("name");
				if (nodeName != null)
				{
					this.scene.addNamedObject(nodeName, nodeGroup);
				}
			}
			else if ("node".equals(parent) && "instance_geometry".equals(name))
			{
				String geometryInstanceUrl = attributes.getValue("url");
				if (geometryInstanceUrl.startsWith("#"))
				{
					final String geometryInstanceAnchor = geometryInstanceUrl.substring(1);
					final String nodeName = attributes.getValue("name");
					final Group parentGroup = new Group();
					this.parentGroups.peek().addChild(parentGroup);
					this.parentGroups.push(parentGroup);
					this.postProcessingBinders.add(new Runnable()
					{
						public void run()
						{
							int nameSuffix = 0;
							// Resolve URL at the end of the document
							for (Geometry geometry : geometries.get(geometryInstanceAnchor))
							{
								Shape3D shape = new Shape3D(geometry);
								parentGroup.addChild(shape);
								// Give a name to shape 
								if (nodeName != null)
								{
									if (nameSuffix == 0)
									{
										scene.addNamedObject(nodeName, shape);
									}
									else
									{
										scene.addNamedObject(nodeName + "_" + nameSuffix, shape);
									}
									nameSuffix++;
								}
							}
						}
					});
				}
			}
			else if ("instance_node".equals(name))
			{
				String nodeInstanceUrl = attributes.getValue("url");
				if (nodeInstanceUrl.startsWith("#"))
				{
					final String nodeInstanceAnchor = nodeInstanceUrl.substring(1);
					final Group parentTransformGroup = this.parentGroups.peek();
					this.postProcessingBinders.add(new Runnable()
					{
						public void run()
						{
							// Resolve URL at the end of the document
							SharedGroup sharedGroup = instantiatedNodes.get(nodeInstanceAnchor);
							if (sharedGroup == null)
							{
								sharedGroup = new SharedGroup();
								sharedGroup.addChild(nodes.get(nodeInstanceAnchor));
								instantiatedNodes.put(nodeInstanceAnchor, sharedGroup);
							}
							parentTransformGroup.addChild(new Link(sharedGroup));
						}
					});
				}
			}
			else if ("instance_material".equals(name) && !this.parentGroups.empty())
			{
				String materialInstanceTarget = attributes.getValue("target");
				if (materialInstanceTarget.startsWith("#"))
				{
					final String materialInstanceAnchor = materialInstanceTarget.substring(1);
					final String materialInstanceSymbol = attributes.getValue("symbol");
					final Group group = this.parentGroups.peek();
					this.postProcessingBinders.add(new Runnable()
					{
						public void run()
						{
							Appearance appearance = effectAppearances.get(materialEffects.get(materialInstanceAnchor));
							updateShapeAppearance(group, appearance);
							try
							{
								appearance.setName(materialNames.get(materialInstanceAnchor));
							}
							catch (NoSuchMethodError ex)
							{
								// Don't set name with Java 3D < 1.4
							}
						}
						
						private void updateShapeAppearance(Node node, Appearance appearance)
						{
							if (node instanceof Group)
							{
								Enumeration<?> enumeration = ((Group) node).getAllChildren();
								while (enumeration.hasMoreElements())
								{
									updateShapeAppearance((Node) enumeration.nextElement(), appearance);
								}
							}
							else if (node instanceof Link)
							{
								updateShapeAppearance(((Link) node).getSharedGroup(), appearance);
							}
							else if (node instanceof Shape3D)
							{
								if (materialInstanceSymbol
										.equals(geometryAppearances.get(((Shape3D) node).getGeometry())))
								{
									((Shape3D) node).setAppearance(appearance);
								}
							}
						}
					});
				}
			}
			else if ("instance_visual_scene".equals(name))
			{
				String visualSceneInstanceUrl = attributes.getValue("url");
				if (visualSceneInstanceUrl.startsWith("#"))
				{
					final String visualSceneInstanceAnchor = visualSceneInstanceUrl.substring(1);
					this.postProcessingBinders.add(new Runnable()
					{
						public void run()
						{
							// Resolve URL at the end of the document
							visualScene = visualScenes.get(visualSceneInstanceAnchor);
						}
					});
				}
			}
			this.parentElements.push(name);
		}
		
		@Override
		public void characters(char[] ch, int start, int length) throws SAXException
		{
			this.buffer.append(ch, start, length);
		}
		
		@Override
		public void endElement(String uri, String localName, String name) throws SAXException
		{
			this.parentElements.pop();
			String parent = this.parentElements.isEmpty() ? null : this.parentElements.peek();
			
			if ("color".equals(name) || "float_array".equals(name) || "matrix".equals(name) || "rotate".equals(name)
					|| "scale".equals(name) || "translate".equals(name))
			{
				String[] floatValues = getCharacters().split("\\s");
				this.floats = new float[floatValues.length];
				int floatCount = 0;
				for (int i = 0; i < floatValues.length; i++)
				{
					if (floatValues[i].length() > 0)
					{
						float floatValue;
						try
						{
							floatValue = Float.parseFloat(floatValues[i]);
						}
						catch (NumberFormatException ex)
						{
							// This may happen with some bad DAE files
							floatValue = 0f;
						}
						this.floats[floatCount++] = floatValue;
					}
				}
				if (floatCount != floatValues.length)
				{
					float[] floats = new float[floatCount];
					System.arraycopy(this.floats, 0, floats, 0, floatCount);
					this.floats = floats;
				}
				if (this.floatArrayId != null)
				{
					this.floatArrays.put(this.floatArrayId, this.floats);
					this.floatArrayId = null;
				}
			}
			else if ("float".equals(name))
			{
				this.floatValue = Float.parseFloat(getCharacters());
			}
			
			if (this.inRootAsset)
			{
				handleRootAssetElementsEnd(name);
			}
			else if ("image".equals(name))
			{
				this.imageId = null;
			}
			else if (this.imageId != null)
			{
				handleImageElementsEnd(name);
			}
			else if ("material".equals(name))
			{
				this.materialId = null;
			}
			else if ("effect".equals(name))
			{
				this.effectId = null;
			}
			else if (this.effectId != null)
			{
				handleEffectElementsEnd(name, parent);
			}
			else if ("geometry".equals(name))
			{
				this.geometryId = null;
			}
			if (this.geometryId != null)
			{
				handleGeometryElementsEnd(name, parent);
			}
			else if ("visual_scene".equals(name) || "node".equals(name)
					|| "node".equals(parent) && "instance_geometry".equals(name))
			{
				this.parentGroups.pop();
			}
			else if ("matrix".equals(name))
			{
				mulTransformGroup(new Transform3D(this.floats));
			}
			else if ("node".equals(parent) && "rotate".equals(name))
			{
				Transform3D rotation = new Transform3D();
				rotation.setRotation(new AxisAngle4f(this.floats[0], this.floats[1], this.floats[2],
						(float) Math.toRadians(floats[3])));
				mulTransformGroup(rotation);
			}
			else if ("scale".equals(name))
			{
				Transform3D scale = new Transform3D();
				scale.setScale(new Vector3d(this.floats[0], this.floats[1], this.floats[2]));
				mulTransformGroup(scale);
			}
			else if ("node".equals(parent) && "translate".equals(name))
			{
				Transform3D translation = new Transform3D();
				translation.setTranslation(new Vector3f(this.floats[0], this.floats[1], this.floats[2]));
				mulTransformGroup(translation);
			}
		}
		
		/**
		 * Returns the trimmed string of last element value. 
		 */
		private String getCharacters()
		{
			return this.buffer.toString().trim();
		}
		
		/**
		 * Handles the end tag of elements children of root "asset".
		 */
		private void handleRootAssetElementsEnd(String name)
		{
			if ("asset".equals(name))
			{
				this.inRootAsset = false;
			}
			else if ("up_axis".equals(name))
			{
				this.axis = getCharacters();
			}
			else if ("subject".equals(name))
			{
				this.scene.addDescription(getCharacters());
			}
			else if ("authoring_tool".equals(name))
			{
				String tool = getCharacters();
				// Try to detect if DAE file was created by Google SketchUp version < 7.1 
				if (tool.startsWith("Google SketchUp"))
				{
					String sketchUpVersion = tool.substring("Google SketchUp".length()).trim();
					if (sketchUpVersion.length() > 0)
					{
						int dotIndex = sketchUpVersion.indexOf('.');
						String majorVersionString = dotIndex == -1 ? sketchUpVersion
								: sketchUpVersion.substring(0, dotIndex);
						try
						{
							int majorVersion = Integer.parseInt(majorVersionString);
							if (majorVersion < 7 || (majorVersion == 7 && (dotIndex >= sketchUpVersion.length() - 1 // No subversion
									|| sketchUpVersion.charAt(dotIndex + 1) < '1')))
							{
								// From http://www.collada.org/public_forum/viewtopic.php?f=12&t=1667
								// let's reverse transparency   
								this.reverseTransparency = true;
							}
						}
						catch (NumberFormatException ex)
						{
							// Too bad we won't know
						}
					}
				}
			}
		}
		
		/**
		 * Handles the end tag of elements children of "image".
		 */
		private void handleImageElementsEnd(String name) throws SAXException
		{
			if ("init_from".equals(name))
			{
				InputStream in = null;
				try
				{
					// Don't interpret possible # sign in URLs as anchor separator
					URL textureImageUrl = new URL(this.baseUrl, getCharacters().replace("#", "%23"));
					in = openStream(textureImageUrl);
					BufferedImage textureImage;
					try
					{
						textureImage = ImageIO.read(in);
					}
					catch (ConcurrentModificationException ex)
					{
						// Try to read the image once more, 
						// see unfixed Java bug http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6986863
						in.close();
						in = openStream(textureImageUrl);
						textureImage = ImageIO.read(in);
					}
					if (textureImage != null)
					{
						TextureLoader textureLoader = new TextureLoader(textureImage);
						Texture texture = textureLoader.getTexture();
						// Keep in user data the URL of the texture image
						texture.setUserData(textureImageUrl);
						this.textures.put(this.imageId, texture);
					}
				}
				catch (IOException ex)
				{
					// Ignore images at other format or not found
				}
				catch (RuntimeException ex)
				{
					// Take into account exceptions of Java 3D 1.5 ImageException class
					// in such a way program can run in Java 3D 1.3.1
					if (ex.getClass().getName().equals("com.sun.j3d.utils.image.ImageException"))
					{
						// Ignore images not supported by TextureLoader
					}
					else
					{
						throw ex;
					}
				}
				finally
				{
					if (in != null)
					{
						try
						{
							in.close();
						}
						catch (IOException ex)
						{
							throw new SAXException("Issue while closing stream", ex);
						}
					}
				}
			}
			else if ("data".equals(name))
			{
				throw new SAXException("<data> not supported");
			}
		}
		
		/**
		 * Handles the end tag of elements children of "effect".
		 */
		private void handleEffectElementsEnd(String name, String parent)
		{
			if ("profile_COMMON".equals(parent) && "newparam".equals(name))
			{
				this.newParamSid = null;
			}
			else if ("newparam".equals(parent) && "surface".equals(name))
			{
				this.inSurface2D = false;
			}
			else if (this.newParamSid != null)
			{
				if (this.inSurface2D && "init_from".equals(name))
				{
					this.surface2DIds.put(this.newParamSid, getCharacters());
				}
				else if ("sampler2D".equals(parent) && "source".equals(name))
				{
					this.sampler2DIds.put(this.newParamSid, getCharacters());
				}
			}
			else if ("extra".equals(parent) && "technique".equals(name))
			{
				this.techniqueProfile = null;
			}
			else if ("phong".equals(name) || "blinn".equals(name) || "lambert".equals(name) || "constant".equals(name))
			{
				float transparencyValue;
				if (this.transparentColor != null)
				{
					if ("RGB_ZERO".equals(this.opaque))
					{
						transparencyValue = this.transparentColor[0] * 0.212671f + this.transparentColor[1] * 0.715160f
								+ this.transparentColor[2] * 0.072169f;
						if (this.transparency != null)
						{
							transparencyValue *= this.transparency.floatValue();
						}
					}
					else
					{ // A_ONE
						if (this.transparency != null)
						{
							transparencyValue = 1 - this.transparentColor[3] * this.transparency.floatValue();
						}
						else
						{
							transparencyValue = 1 - this.transparentColor[3];
						}
						if (this.reverseTransparency)
						{
							transparencyValue = 1 - transparencyValue;
						}
					}
				}
				else
				{
					transparencyValue = 0;
				}
				Appearance appearance = this.effectAppearances.get(this.effectId);
				if (transparencyValue > 0)
				{
					appearance.setTransparencyAttributes(
							new TransparencyAttributes(TransparencyAttributes.NICEST, transparencyValue)); // 0 means opaque in Java 3D
				}
				else
				{
					// Set default color if it doesn't exist yet
					Color3f black = new Color3f();
					if (appearance.getMaterial() == null)
					{
						appearance.setMaterial(new Material(black, black, black, black, 1));
						appearance
								.setColoringAttributes(new ColoringAttributes(black, ColoringAttributes.SHADE_GOURAUD));
					}
				}
				this.transparentColor = null;
				this.transparency = null;
				
				this.inPhongBlinnOrLambert = false;
				this.inConstant = false;
			}
			else if (this.inConstant || this.inPhongBlinnOrLambert)
			{
				// Set appearance attributes
				if ("color".equals(name))
				{
					if ("emission".equals(parent))
					{
						if (this.inPhongBlinnOrLambert)
						{
							getAppearanceMaterial(this.effectId).setEmissiveColor(this.floats[0], this.floats[1],
									this.floats[2]);
						}
						else
						{ // inConstant
							this.effectAppearances.get(this.effectId).setColoringAttributes(new ColoringAttributes(
									this.floats[0], this.floats[1], this.floats[2], ColoringAttributes.SHADE_GOURAUD));
						}
					}
					else if ("ambient".equals(parent))
					{
						getAppearanceMaterial(this.effectId).setAmbientColor(this.floats[0], this.floats[1],
								this.floats[2]);
					}
					else if ("diffuse".equals(parent))
					{
						getAppearanceMaterial(this.effectId).setDiffuseColor(this.floats[0], this.floats[1],
								this.floats[2], this.floats[3]);
						// Always set coloring attributes in case geometries don't contain normals
						this.effectAppearances.get(this.effectId).setColoringAttributes(new ColoringAttributes(
								this.floats[0], this.floats[1], this.floats[2], ColoringAttributes.SHADE_GOURAUD));
					}
					else if ("specular".equals(parent))
					{
						getAppearanceMaterial(this.effectId).setSpecularColor(this.floats[0], this.floats[1],
								this.floats[2]);
					}
					else if ("transparent".equals(parent))
					{
						this.transparentColor = this.floats;
					}
				}
				else if ("float".equals(name))
				{
					if ("shininess".equals(parent))
					{
						getAppearanceMaterial(this.effectId).setShininess(this.floatValue);
					}
					else if ("transparency".equals(parent))
					{
						this.transparency = this.floatValue;
					}
				}
			}
			else if ("double_sided".equals(name) && "1".equals(getCharacters())
					&& ("GOOGLEEARTH".equals(this.techniqueProfile) || "MAX3D".equals(this.techniqueProfile)
							|| "MAYA".equals(this.techniqueProfile)))
			{
				this.effectAppearances.get(this.effectId).setPolygonAttributes(
						new PolygonAttributes(PolygonAttributes.POLYGON_FILL, PolygonAttributes.CULL_NONE, 0));
			}
		}
		
		/**
		 * Returns the material of the appearance at <code>effectId</code>. 
		 */
		private Material getAppearanceMaterial(String effectId)
		{
			Appearance appearance = this.effectAppearances.get(effectId);
			Material material = appearance.getMaterial();
			if (material == null)
			{
				material = new Material();
				appearance.setMaterial(material);
			}
			return material;
		}
		
		/**
		 * Handles the end tag of elements children of "geometry".
		 */
		private void handleGeometryElementsEnd(String name, String parent)
		{
			if ("mesh".equals(parent) && "source".equals(name))
			{
				this.sources.put(this.meshSourceId, this.floats);
				this.meshSourceId = null;
			}
			else if ("mesh".equals(parent) && "vertices".equals(name))
			{
				this.verticesId = null;
			}
			else if ("p".equals(name) || "h".equals(name) || "vcount".equals(name))
			{
				// Get integers
				String[] intValues = getCharacters().split("\\s");
				int[] integers = new int[intValues.length];
				int intCount = 0;
				for (int i = 0; i < intValues.length; i++)
				{
					if (intValues[i].length() > 0)
					{
						integers[intCount++] = Integer.parseInt(intValues[i]);
					}
				}
				if (intCount != intValues.length)
				{
					int[] ints = new int[intCount];
					System.arraycopy(integers, 0, ints, 0, intCount);
					integers = ints;
				}
				
				if (!"ph".equals(parent) && "p".equals(name))
				{
					this.facesAndLinesPrimitives.add(integers);
				}
				else if ("vcount".equals(name))
				{
					this.vcount = integers;
				}
				else if ("ph".equals(parent))
				{
					if ("p".equals(name))
					{
						this.polygonsPrimitives.add(integers);
					}
					else if ("h".equals(name))
					{
						if (this.polygonsPrimitives.size() > this.polygonsHoles.size())
						{
							this.polygonsHoles.add(new ArrayList<int[]>());
						}
						this.polygonsHoles.get(this.polygonsPrimitives.size() - 1).add(integers);
					}
				}
			}
			else if (("triangles".equals(name) || "trifans".equals(name) || "tristrips".equals(name)
					|| "polylist".equals(name) || "polygons".equals(name) || "lines".equals(name)
					|| "linestrips".equals(name))
					// Ignore geometries with missing vertices
					&& this.geometryVertices != null)
			{
				Geometry geometry;
				if ("lines".equals(name) || "linestrips".equals(name))
				{
					geometry = getLinesGeometry(name);
				}
				else
				{
					geometry = getFacesGeometry(name);
				}
				if (geometry != null)
				{
					this.geometries.get(this.geometryId).add(geometry);
					if (this.geometryAppearance != null)
					{
						this.geometryAppearances.put(geometry, this.geometryAppearance);
					}
				}
				this.geometryAppearance = null;
				this.geometryVertices = null;
				this.geometryNormals = null;
				this.geometryTextureCoordinates = null;
				this.facesAndLinesPrimitives.clear();
				this.polygonsPrimitives.clear();
				this.polygonsHoles.clear();
				this.vcount = null;
			}
		}
		
		/**
		 * Returns the triangles or polygons geometry matching the read values.
		 */
		private Geometry getFacesGeometry(String name)
		{
			int primitiveType;
			if ("triangles".equals(name))
			{
				primitiveType = GeometryInfo.TRIANGLE_ARRAY;
			}
			else if ("trifans".equals(name))
			{
				primitiveType = GeometryInfo.TRIANGLE_FAN_ARRAY;
			}
			else if ("tristrips".equals(name))
			{
				primitiveType = GeometryInfo.TRIANGLE_STRIP_ARRAY;
			}
			else
			{
				primitiveType = GeometryInfo.POLYGON_ARRAY;
			}
			
			GeometryInfo geometryInfo = new GeometryInfo(primitiveType);
			geometryInfo.setCoordinates(this.geometryVertices);
			geometryInfo.setCoordinateIndices(getIndices(this.geometryVertexOffset));
			if (this.geometryNormals != null)
			{
				geometryInfo.setNormals(this.geometryNormals);
				geometryInfo.setNormalIndices(getIndices(this.geometryNormalOffset));
			}
			if (this.geometryTextureCoordinates != null)
			{
				Integer stride = this.sourceAccessorStrides.get(this.geometryTextureCoordinates);
				// Support only UV texture coordinates
				float[] textureCoordinates;
				if (stride > 2)
				{
					textureCoordinates = new float[this.geometryTextureCoordinates.length / stride * 2];
					for (int i = 0, j = 0; j < this.geometryTextureCoordinates.length; j += stride)
					{
						textureCoordinates[i++] = this.geometryTextureCoordinates[j];
						textureCoordinates[i++] = this.geometryTextureCoordinates[j + 1];
					}
				}
				else
				{
					textureCoordinates = this.geometryTextureCoordinates;
				}
				geometryInfo.setTextureCoordinateParams(1, 2);
				geometryInfo.setTextureCoordinates(0, textureCoordinates);
				geometryInfo.setTextureCoordinateIndices(0, getIndices(this.geometryTextureCoordinatesOffset));
			}
			
			if ("trifans".equals(name) || "tristrips".equals(name))
			{
				int[] stripCounts = new int[this.facesAndLinesPrimitives.size()];
				for (int i = 0; i < stripCounts.length; i++)
				{
					stripCounts[i] = this.facesAndLinesPrimitives.get(i).length / this.inputCount;
				}
				geometryInfo.setStripCounts(stripCounts);
			}
			else if ("polylist".equals(name))
			{
				geometryInfo.setStripCounts(this.vcount);
			}
			else if ("polygons".equals(name))
			{
				int polygonHolesCount = 0;
				for (List<int[]> polygonHoles : this.polygonsHoles)
				{
					polygonHolesCount += polygonHoles.size();
				}
				int[] stripCounts = new int[this.facesAndLinesPrimitives.size() + this.polygonsPrimitives.size()
						+ polygonHolesCount];
				int[] contourCounts = new int[this.facesAndLinesPrimitives.size() + this.polygonsPrimitives.size()];
				int stripIndex = 0;
				int countourIndex = 0;
				for (int i = 0; i < this.facesAndLinesPrimitives.size(); i++)
				{
					stripCounts[stripIndex++] = this.facesAndLinesPrimitives.get(i).length / this.inputCount;
					contourCounts[countourIndex++] = 1; // One polygon 
				}
				for (int i = 0; i < this.polygonsPrimitives.size(); i++)
				{
					stripCounts[stripIndex++] = this.polygonsPrimitives.get(i).length / this.inputCount;
					List<int[]> polygonHoles = this.polygonsHoles.get(i);
					for (int j = 0; j < polygonHoles.size(); j++)
					{
						stripCounts[stripIndex++] = polygonHoles.get(j).length / this.inputCount;
					}
					contourCounts[countourIndex++] = 1 + polygonHoles.size(); // One polygon + its holes count
				}
				geometryInfo.setStripCounts(stripCounts);
				geometryInfo.setContourCounts(contourCounts);
			}
			
			if (this.geometryNormals == null)
			{
				new NormalGenerator(Math.PI / 2).generateNormals(geometryInfo);
			}
			return geometryInfo.getGeometryArray(true, true, false);
		}
		
		/**
		 * Returns the lines geometry matching the read values.
		 */
		private Geometry getLinesGeometry(String name)
		{
			int format = IndexedGeometryArray.COORDINATES;
			if (this.geometryNormals != null)
			{
				format |= IndexedGeometryArray.NORMALS;
			}
			if (this.geometryTextureCoordinates != null)
			{
				format |= IndexedGeometryArray.TEXTURE_COORDINATE_2;
			}
			
			int[] coordinatesIndices = getIndices(this.geometryVertexOffset);
			if (coordinatesIndices.length != 0)
			{
				IndexedGeometryArray geometry;
				if ("lines".equals(name))
				{
					geometry = new IndexedLineArray(this.geometryVertices.length / 3, format,
							coordinatesIndices.length);
				}
				else
				{ // linestrips
					int[] stripCounts = new int[this.facesAndLinesPrimitives.size()];
					for (int i = 0; i < stripCounts.length; i++)
					{
						stripCounts[i] = this.facesAndLinesPrimitives.get(i).length / this.inputCount;
					}
					geometry = new IndexedLineStripArray(this.geometryVertices.length / 3, format,
							coordinatesIndices.length, stripCounts);
				}
				
				geometry.setCoordinates(0, this.geometryVertices);
				geometry.setCoordinateIndices(0, coordinatesIndices);
				if (this.geometryNormals != null)
				{
					geometry.setNormals(0, this.geometryNormals);
					geometry.setNormalIndices(0, getIndices(this.geometryNormalOffset));
				}
				if (this.geometryTextureCoordinates != null)
				{
					geometry.setTextureCoordinates(0, 0, this.geometryTextureCoordinates);
					geometry.setTextureCoordinateIndices(0, 0, getIndices(this.geometryTextureCoordinatesOffset));
				}
				return geometry;
			}
			else
			{
				// Ignore lines with an empty index set
				return null;
			}
		}
		
		/**
		 * Returns the indices at the given <code>indexOffset</code>.
		 */
		private int[] getIndices(int indexOffset)
		{
			if (this.facesAndLinesPrimitives.size() == 1 && this.polygonsPrimitives.size() == 1 && this.inputCount == 1)
			{
				return facesAndLinesPrimitives.get(0);
			}
			else
			{
				int indexCount = getIndexCount(this.facesAndLinesPrimitives);
				indexCount += getIndexCount(this.polygonsPrimitives);
				for (List<int[]> polygonHole : this.polygonsHoles)
				{
					indexCount += getIndexCount(polygonHole);
				}
				
				int[] indices = new int[indexCount / this.inputCount];
				int i = 0;
				for (int[] primitives : this.facesAndLinesPrimitives)
				{
					for (int k = indexOffset; k < primitives.length; k += this.inputCount)
					{
						indices[i++] = primitives[k];
					}
				}
				for (int j = 0; j < this.polygonsPrimitives.size(); j++)
				{
					int[] polygonPrimitives = polygonsPrimitives.get(j);
					for (int k = indexOffset; k < polygonPrimitives.length; k += this.inputCount)
					{
						indices[i++] = polygonPrimitives[k];
					}
					for (int[] polygonHole : this.polygonsHoles.get(j))
					{
						for (int k = indexOffset; k < polygonHole.length; k += this.inputCount)
						{
							indices[i++] = polygonHole[k];
						}
					}
				}
				return indices;
			}
		}
		
		/**
		 * Returns the total count of indices among the given <code>faceIndices</code>. 
		 */
		private int getIndexCount(List<int[]> faceIndices)
		{
			int indexCount = 0;
			for (int[] indices : faceIndices)
			{
				indexCount += indices.length;
			}
			return indexCount;
		}
		
		/**
		 * Multiplies the transform at top of the transform groups stack by the 
		 * given <code>transformMultiplier</code>.
		 */
		private void mulTransformGroup(Transform3D transformMultiplier)
		{
			TransformGroup transformGroup = (TransformGroup) this.parentGroups.peek();
			Transform3D transform = new Transform3D();
			transformGroup.getTransform(transform);
			transform.mul(transformMultiplier);
			transformGroup.setTransform(transform);
		}
		
		@Override
		public void endDocument() throws SAXException
		{
			for (Runnable runnable : this.postProcessingBinders)
			{
				runnable.run();
			}
			
			if (this.visualScene != null)
			{
				Transform3D rootTransform = new Transform3D();
				this.visualScene.getTransform(rootTransform);
				
				BoundingBox bounds = new BoundingBox(
						new Point3d(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY),
						new Point3d(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY));
				computeBounds(this.visualScene, bounds, new Transform3D());
				
				// Translate model to its center
				Point3d lower = new Point3d();
				bounds.getLower(lower);
				if (lower.x != Double.POSITIVE_INFINITY)
				{
					Point3d upper = new Point3d();
					bounds.getUpper(upper);
					Transform3D translation = new Transform3D();
					translation.setTranslation(new Vector3d(-lower.x - (upper.x - lower.x) / 2,
							-lower.y - (upper.y - lower.y) / 2, -lower.z - (upper.z - lower.z) / 2));
					translation.mul(rootTransform);
					rootTransform = translation;
				}
				
				// Scale model to cm
				Transform3D scaleTransform = new Transform3D();
				scaleTransform.setScale(this.meterScale * 100);
				scaleTransform.mul(rootTransform);
				
				// Set orientation to Y_UP
				Transform3D axisTransform = new Transform3D();
				if ("Z_UP".equals(axis))
				{
					axisTransform.rotX(-Math.PI / 2);
				}
				else if ("X_UP".equals(axis))
				{
					axisTransform.rotZ(Math.PI / 2);
				}
				axisTransform.mul(scaleTransform);
				
				this.visualScene.setTransform(axisTransform);
				
				BranchGroup sceneRoot = new BranchGroup();
				this.scene.setSceneGroup(sceneRoot);
				sceneRoot.addChild(this.visualScene);
			}
		}
		
		/**
		 * Combines the given <code>bounds</code> with the bounds of the given <code>node</code>
		 * and its children.
		 */
		private void computeBounds(Node node, BoundingBox bounds, Transform3D parentTransformations)
		{
			if (node instanceof Group)
			{
				if (node instanceof TransformGroup)
				{
					parentTransformations = new Transform3D(parentTransformations);
					Transform3D transform = new Transform3D();
					((TransformGroup) node).getTransform(transform);
					parentTransformations.mul(transform);
				}
				// Compute the bounds of all the node children
				Enumeration<?> enumeration = ((Group) node).getAllChildren();
				while (enumeration.hasMoreElements())
				{
					computeBounds((Node) enumeration.nextElement(), bounds, parentTransformations);
				}
			}
			else if (node instanceof Link)
			{
				computeBounds(((Link) node).getSharedGroup(), bounds, parentTransformations);
			}
			else if (node instanceof Shape3D)
			{
				Bounds shapeBounds = ((Shape3D) node).getBounds();
				shapeBounds.transform(parentTransformations);
				bounds.combine(shapeBounds);
			}
		}
	}
}
